Skip to content

feat(plugin-sharing): expand the field sharing recipient per matched record — the services half of the #14103 ruling - #15235

Draft
os-warren wants to merge 6 commits into
mainfrom
claude/issue-15072-field-sharing-recipient
Draft

feat(plugin-sharing): expand the field sharing recipient per matched record — the services half of the #14103 ruling#15235
os-warren wants to merge 6 commits into
mainfrom
claude/issue-15072-field-sharing-recipient

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #15072

The domain:services half of the maintainer ruling on #14103 (issuecomment-5507512776, director summon #8, 2026-09-02, verbatim 「同意」 adopting B). Quoted verbatim — the one angle-bracket placeholder is spelled USER-FIELD-NAME here because the GitHub body sanitizer eats angle-bracket fragments; nothing else is changed:

Ruled: B. ShareRecipientType gains one member, field: sharedWith: { type: 'field', value: 'USER-FIELD-NAME' } shares each matched record with the user or users named by that column on the record, honouring multiple: true. expandRecipient becomes per-record for that member only. ⛔ No manager member.

Draft on purpose: Clause-② is yes on this card (the declared-rule bootstrap's accept set widens — a field rule was refused at mapRecipientTypedefault: return null and is accepted now), so this PR carries needs:contract-review and does not go ready / armed / enqueued until seat-internal contract review returns PASS.

Executor contract (comment 5532175167) — the seven points, as landed

  1. Per record for field onlySharingRuleService.matchRecord (was recordMatches(): boolean) projects the recipient column beside id for a field rule, so one query answers "does this record match" and "whom does it name"; expandRecipientForRecord is the per-record switch and expands field from the row. user / team / position / business_unit / unit_and_subordinates keep the rule-wide expandRecipient, memoised across the pass exactly as before. Pin: a position recipient still expands rule-wide — three matched records, one sys_user_position read, identical holder set on every record (field-recipient.test.ts, "the ruling's explicit pin").
  2. multiple: trueusersNamedBy accepts the two stored shapes a user-valued column has (one id, or an array of ids), de-duplicated. A string is one id, never JSON to parse: the drivers hydrate multi-value columns before a row reaches engine.find, and a parse here would fossilise a driver defect into a second contract (AGENTS.md PD Add comprehensive test suite for Zod schema validation #12).
  3. Fail-closed on emptynull, undefined, '', [], whitespace, and a non-id value (an object) each materialise no grant; there is no match-all principal and no fallback to owner_id (pinned: the owner is asserted absent from the grantees). An emptied column revokes the grant it once earned. An empty column is deliberately not warned about — an unassigned record sharing with nobody is the declared semantics.
  4. Re-materialisation on the record's own writeno second trigger. The hard precondition was DISCHARGED before dispatch (comment 5535095827, CONFIRMED on origin/main fcc42e6c1d: rule-hooks.ts afterUpdate has no changed-field gating; recomputeRowevaluateAllForRecord → per-record expand + reconcileForRecord) and was not re-run. The pin drives the real bindRuleHooks binding: an update whose patch carries ONLY assignees revokes the old user's grant and materialises the new one; widening, clearing, and a criteria-exit write are pinned too; and the bound hook set for the object is asserted to be the existing five events — nothing registered for the recipient column.
  5. Authoring seamsbootstrap-declared-sharing-rules.ts mapRecipientType gains case 'field': return 'field' (pinned: a declared field rule seeds with the field name as recipientId, no skip warning; a manager kind is still skipped as unmappable — the control). sys_sharing_rule.recipient_type (packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts — the card named the file without a path; it is not under platform-objects) gains 'field', pinned as a superset of ShareRecipientType.options so no authorable member can be refused at the select again; the recipient_id help text says the value is the name of the record field for that type (admin-facing wording, no table/column/ADR names, per that object's own rule).
  6. The "until plugin-sharing: expand the ruled field sharing recipient per record — expandRecipient reads the user field on the matched row, re-materialises on that record's own update, position stays rule-wide (services half of #14103, ruling B) #15072 lands" sentences — retired in content/docs/permissions/sharing-rules.mdx (found by re-grep; not on the contract's list), content/docs/permissions/permissions-matrix.mdx and content/docs/protocol/objectql/security.mdx (both spots). ⛔ Not the two in packages/spec/src/security/sharing.zod.ts (:91 and :262) — packages/spec is a single-owner lane this dispatch forbids me to edit, and that TSDoc feeds the generated content/docs/references/ tree, which would widen this diff into spec-lane artifacts. They are left for the spec seat and named in the report. content/docs/releases/** untouched.
  7. No manager member anywhere. defineRule additionally refuses a field recipient whose recipientId is not a field name (/^[a-z_][a-z0-9_]*$/, the FieldSchema.name grammar the spec applies at parse) — that entry (REST POST /sharing/rules, programmatic authoring) never runs the spec schema, so without it owner.manager_id — a graph walk spelled as a value — would reach the executor and share with nobody in silence. Scoped to field: a user recipient keeps its opaque id (pinned).

The two rule-wide call sites (comment 5535095827) — decided, each with a pin

The rule-wide expandRecipient is now refused for field (a thrown error, never []): answering nobody is precisely how a rule-wide caller turns "per record" into "revoke everything". The three callers each decide explicitly:

site decision reading
evaluateAllForRecord (per record) expandRecipientForRecord — the contract's target
evaluateRule (rule-wide; behind revokeThenQueueRegrantevaluateAllRulesForObject, the kernel:bootstrapped backfill and REST evaluate) made correct, not skipped. desiredGrantsForRule builds the desired set per kind: rule-wide members keep matched ids × one expansion; field reads the matched ROWS with the recipient column projected (one criteria read, no per-record round trip) and emits (record, user) pairs. reconcile now diffs a DesiredGrantSet and no longer knows which kind it is diffing. PM assumption 1 confirmed for this site: it is reachable for field rules on every unbounded bulk write and every restart, and [] there would have revoked every grant until each record was next written (fail-closed, but wrong). Pinned: per-record pairs with no cross product, a stale cross-product grant revoked, the revoke-object → evaluateAllRulesForObject path restoring exactly the per-record set, idempotence, inactive-rule purge, and no "object reconcile failed" warn.
revokeRuleGrantsForRetiredRecipients (rule-wide) declined at the door: field returns 0 before the grant read. A field rule has no rule-wide recipient set to retire against; its recipients are re-derived by the record's own write and by the whole-rule pass. Measured not reachable today: its one production caller, bu-tree-recompute.ts onGraphWrite, filters rules on BU_TREE_RECIPIENT_TYPES = {business_unit, unit_and_subordinates} before calling it. The guard makes that a contract rather than a coincidence. Pinned three ways: the field rule answers 0 and its three grants stand (the grant table is not even read); a user rule beside it still retires a stale recipient (control); the real bindBusinessUnitTreeRecompute binding, fired on a sys_business_unit_member insert with a BU rule and a field rule present, hands in only the BU rule.

The owner-recipient removal reasoning, as the ruling asked

packages/spec/src/security/sharing.zod.ts (SharingRuleType docblock): owner-type rules were removed from the authoring surface because "they depend on live team/position membership, which the static materialiser cannot track (a membership change would have to re-materialise every dependent rule)"; content/docs/protocol/objectql/security.mdx "Owner-Based Sharing — removed in v17" (#1878): they "validated but never materialized a share" (ADR-0078). The field member is the shape that reasoning permits: the recipient is a column on the record, so it moves only when the record moves, and the record's own write is exactly the event the existing materialiser already re-runs on. A manager member would re-introduce the removed obligation (a sys_user write the record materialiser never sees) — which is why the ruling refuses it and why nothing here walks off the row.

The column must hold users (fail-closed, loud)

fieldRecipientColumnIsUsable asks the engine's schema on every call (never cached at boot — AGENTS.md "Startup registry reads"): a user field or a lookup / master_detail with reference: 'sys_user' is read; a column of another type or one the object does not declare grants nobody and warns once per rule per cause (no-such-field / not-user-typed); a read the engine refuses (INVALID_FIELD) fails closed and warns once (read-failed). With no schema to consult the column is read on its declared semantics. Pinned in both directions, including that an unusable column still revokes what the rule once held.

PM mechanism assumptions — verdicts

  1. Two rule-wide sites are the real work — confirmed for site 2, refined for site 3 (not reachable today; guarded and pinned anyway, see table).
  2. Path of sys-sharing-rule.object.tsconfirmed at packages/plugins/plugin-sharing/src/objects/.
  3. Accept-set reading behind Clause ② — confirmed on 919beca: mapRecipientType ended default: return null (:101:114) and :223 counted it as skipped with a warn; the select listed five values.
  4. Serial constraint PR fix(plugin-sharing): per-grant catch in both reconcile loops so one refused grant no longer aborts the pass (#14754) #14930taken the file; not coordinated, as instructed.

H17: the delivered diff reaches sharing-rule-service.ts, bu-tree-recompute.ts (docblock only) and bootstrap-declared-sharing-rules.ts; not sharing-service.ts — no Restart-touch notice owed to #6736.

Surface beyond the claim, declared

  • content/docs/permissions/system-context.mdx:140 — two line anchors into sharing-rule-service.ts (:165:241, :390:466) moved with the inserted module helpers; rewritten by check:system-context-census --fix (a shift, the population is unchanged).
  • scripts/engine-double-contract.pinned.json — the new suite's fake engine recorded by check:engine-double-contract --write (the gate's own instruction for new pinned coverage).
  • packages/plugins/plugin-sharing/src/translations/* — regenerated by check-i18n-bundles.mjs --write for the new select option and the two help strings; the zh-CN / ja-JP / es-ES leaves are hand-translated (not English fills).
  • .changeset/plugin-sharing-field-recipient.mdminor: a new recipient kind is enforced by a released package.

Tests

packages/plugins/plugin-sharing/src/field-recipient.test.ts — 52 pins across the seven sections above. Whole package: pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2Test Files 33 passed (33) · Tests 853 passed (853); pnpm --filter @objectstack/plugin-sharing typecheckcheck:test-typecheck: OK. Gate union and the head it was measured on: see the report comment on #15072 (os-dev-report).

Out of scope, filed: objectui#7613 (the Setup recipient picker has no field mode; it degrades to its text input). #14930 is not addressed here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

…d record

The services half of the #14103 ruling (B): `expandRecipient` becomes
per-record for the `field` member only. The per-record pass reads the
named user-typed column off the matched row (one query for match and
recipients), honours `multiple: true`, and fails closed on an empty column.
The whole-rule pass (`evaluateRule`, behind the background re-grant, the
boot backfill and the REST evaluate endpoint) derives per-record pairs
instead of a matched-records x recipients product; the recipient-axis
revoke declines `field` rules; the rule-wide switch refuses the member
loudly. The bootstrap seeds `field` rules, the `sys_sharing_rule` select
stores them, `defineRule` holds the recipient to the field-name grammar,
and the "until the executor lands" doc sentences are retired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…re-anchor the shifted system-context citations

The new suite's fake engine is recorded in scripts/engine-double-contract.pinned.json
(the gate's own --write), and the two elevation-read anchors the system-context page
carries into sharing-rule-service.ts moved with the inserted module helpers
(the census gate's own --fix; a shift, not a population change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ext; keep the tracker id out of the runtime string

The regenerated bundles carry the new select option and the two changed help
strings; the zh-CN / ja-JP / es-ES leaves are translated rather than English
fills, so no source-hash provenance row is left behind. The rule-wide refusal
message drops its issue reference (check:doc-authoring) — the id lives in the
adjacent comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing, touching 37 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-sharing/src/bu-tree-recompute.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

51 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 1bc3c092a118885671d85bff5259d82fe40d9ad6.

8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-sharing/src/bu-tree-recompute.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 30 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1bc3c092a118885671d85bff5259d82fe40d9ad6packageMentionDocs.

Which tree this was computed on

This run read content/docs from d3590d8a1f5e0d070a1a854ca1f1c0801ff19c23 — the merge of head fe9281910a564c069763d5116331d544f0bc5aea into base 1bc3c092a118885671d85bff5259d82fe40d9ad6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d3590d8a1f5e0d070a1a854ca1f1c0801ff19c23 && git checkout d3590d8a1f5e0d070a1a854ca1f1c0801ff19c23
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1bc3c092a118885671d85bff5259d82fe40d9ad6 fe9281910a564c069763d5116331d544f0bc5aea && git checkout -B drift-repro 1bc3c092a118885671d85bff5259d82fe40d9ad6 && git merge --no-ff fe9281910a564c069763d5116331d544f0bc5aea

node scripts/docs-audit/affected-docs.mjs --json 1bc3c092a118885671d85bff5259d82fe40d9ad6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1bc3c092a118885671d85bff5259d82fe40d9ad6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…eld-sharing-recipient

# Conflicts:
#	packages/plugins/plugin-sharing/src/sharing-rule-service.ts
… merged tree; re-sync the engine-double ledger

The os-regen merge driver does not text-merge system-context.mdx; regenerated with
pnpm gen:system-context-census on the committed merge as its own commit. The
engine-double-contract ledger is re-written by the gate's own --write on the same tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…pient compose

After merging #14930 into this branch the two changes share reconcile: the
whole-rule pass diffs a DesiredGrantSet and attempts each grant individually.
Pinned in both directions on the field kind — a refused grant is counted and
the pass (with its stale-row revocations) continues on both reconcile paths;
the catch stays narrow on an unrelated engine error; the rule-wide switch's
refusal of a field rule carries no engine code and is never reached by a
production pass. Also tidies the blank line the merge left before reconcile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants